Skip to content

feat: version range support for package dependencies#491

Draft
Kamirus wants to merge 9 commits intomainfrom
feat/version-ranges
Draft

feat: version range support for package dependencies#491
Kamirus wants to merge 9 commits intomainfrom
feat/version-ranges

Conversation

@Kamirus
Copy link
Copy Markdown
Collaborator

@Kamirus Kamirus commented Apr 14, 2026

Status: paused — exploring alternatives

This PR is on hold while we explore a less invasive design (see "Why we're pausing" below). Pushing the current state for posterity and so others can refer to it.

Problem

Mops currently requires exact version pins for all dependencies (e.g. core = "1.2.3"). Library authors cannot express compatibility constraints like "my library works with any core 1.x >= 1.2.3", and application developers must manually bump every transitive dependency version — there is no way for mops install to automatically pick up compatible patch/minor updates.

What this PR does

Adds caret (^) and tilde (~) version range operators, following npm/Cargo conventions:

  • Caret ^1.2.3 = >=1.2.3, <2.0.0
  • Tilde ~1.2.3 = >=1.2.3, <1.3.0
  • Pre-1.0 caret follows Cargo semantics: ^0.2.3 = >=0.2.3, <0.3.0
  • Bare versions remain exact pins (backward compatible)

CLI behavior:

  • mops add core writes core = "^x.y.z" (caret default)
  • mops add core@1.2.3 writes exact pin (unchanged)
  • mops update preserves range type and bumps the lower bound
  • Resolver validates transitive constraints and reports conflicts

Why we're pausing

Two issues surfaced during review that make this design more disruptive than it first looked.

1. Backend canister doesn't accept ranges

backend/main/utils/validateConfig.mo runs strict Semver.validate (x.y.z only) on every dependency in a published package's config, and PackagePublisher.mo does an exact registry.getPackageConfig(name, dep.version) lookup. A package published with core = "^1.2.3" is rejected by the canister at publish time.

The CLI prints a warning, but the publish still fails — the warning is misleading. Properly supporting ranges in published packages requires backend changes (relaxed validation + range-aware lookup), which means coordinating a canister upgrade.

2. The breaking-change surface is wider than expected

Even with the publish path fixed, every consumer reading a published mops.toml containing ^/~ needs a new-enough CLI to interpret it. Old CLIs would treat ^1.2.3 as an exact version literal, fail to find it, and break. This is a registry-wide one-way door: the moment any popular package adopts ranges, it forks the ecosystem by CLI version.

There is no good way to gate this behind an opt-in flag either, because the on-wire format change propagates to every downstream consumer.

3. Mops already does "compatible-ish" resolution implicitly

Today, when two packages depend on different exact versions of the same dep, the resolver picks the highest one. Effectively most users already get caret-like behavior for transitive deps without writing ^. The marginal value of explicit ^/~ syntax is smaller than expected, and concentrated on letting library authors narrow (with ~) rather than widen the contract — a less common need.

Alternative being explored

A separate PR will explore a Cargo-inspired approach hidden behind an opt-in experimental flag in mops.toml:

experimental = ["compatible-resolution"]

When enabled, bare versions in the consumer's own mops.toml are interpreted as caret ranges at resolve time. Crucially:

  • No syntax change to mops.toml (no ^/~)
  • No format change to published configs
  • No backend changes
  • No old-CLI breakage
  • Opt-in per project

This is a much narrower experiment that targets the same outcome (automatic compatible patch/minor pickup) without the registry-wide blast radius. See follow-up PR (TBD).

What's salvageable from this PR

  • The semver package integration and cli/semver.ts helpers
  • The lockfile-shortcut bypass for explicit conflict detection (W1 fix)
  • General resolver simplifications (stateless cache helper, tuple destructuring, redundant-check removal)

These can be extracted into smaller PRs independent of the range-syntax decision.

Test plan

  • 18 unit tests for isRange, stripRangePrefix, rangeToSemverPart
  • TypeScript + ESLint + Prettier clean
  • Not landed — see status above

Kamirus added 9 commits April 14, 2026 17:54
Support caret (^) and tilde (~) version range operators in mops.toml
dependencies, similar to npm and Cargo.

- `mops add` now defaults to caret range (e.g., core = "^1.2.3")
- Ranges resolved to highest satisfying version during install
- Lock file stores exact resolved versions for reproducibility
- Backend validates ^/~ prefixed versions in published configs
- New getPackageVersions API endpoint for client-side resolution
- Resolver validates all range constraints are satisfied post-resolution

Made-with: Cursor
- Reduce cli/semver.ts from 135 to 14 lines (thin wrapper with isRange/stripRangePrefix)
- Use semver.maxSatisfying, semver.satisfies, semver.compare from the already-installed semver@7.7.1
- Cache directory listing in findCachedVersions to avoid N redundant readdirSync calls
- Remove redundant stripRangePrefix call on exact deps in available-updates.ts
- Slim tests from 202 to 47 lines (only test mops-specific helpers, not npm semver semantics)

Made-with: Cursor
- Use lock file resolved version (not range floor) when checking if
  ranged deps have available updates
- Add resetCachedDirEntries() and invalidate before resolvePackages
  to ensure freshly installed packages are visible

Made-with: Cursor
…ranges

The existing getHighestSemverBatch endpoint already covers range
resolution — rangeToSemverPart maps ^/~ to the correct SemverPart
(#major, #minor, #patch). This eliminates a new backend endpoint,
the N+1 call problem, and the dedicated API wrapper.

Made-with: Cursor
The SemverPart mapping was inverted — #major means "any higher"
not "same major". Fixed: ^X.Y.Z→#minor, ~X.Y.Z→#patch, ^0.0.x→null
(exact pin).

Removed all backend canister changes (semver.mo, validateConfig.mo)
except the "fromat" typo fix. publish.ts now strips range prefixes
before sending config to the backend, so no canister upgrade needed.

Also: fix ESLint/Prettier violations, dedupe update.ts loop,
remove low-info comments, explicit error on empty batch result.

Made-with: Cursor
- Drop module-level cache state in cli/cache.ts; replace findCachedVersions +
  resetCachedDirEntries with stateless listCachedPackages
- Inline range resolution in resolvePackages; comparison uses stripRangePrefix
  + semver.compare directly (no cache lookup, no non-null assertion)
- Run resolver's full graph walk when conflicts != "ignore" so the
  range-satisfaction check actually fires after mops add/update
- mops add core@^x.y.z now passes the range to installMopsDep (was installing
  the floor instead of highest matching)
- rangeToSemverPart returns null on invalid input instead of silently
  defaulting to {minor: null}
- Skip per-constraint check when major-mismatch was already reported
- Warn (not reject) on publish with ranged deps; older CLI versions cannot
  install such packages
- Restore unrelated comments to reduce churn

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant